iT邦幫忙

2022 iThome 鐵人賽

DAY 16
1
Modern Web

真的好想離開 Vue 3 新手村 feat. CompositionAPI系列 第 16

Day 16: 從 vuejs 原始碼看 v-on 修飾符串接

  • 分享至 

  • xImage
  •  

誰適合看這篇?

試問: @click.prevent.self@click.self.prevent 的差別是什麼?
...
回答不出來的人,還有想即時打開 Vue 文件找答案的人,可以看看這篇XD。

前言

Vue 在 Event Handling 這個章節中提到,v-on 的修飾符可以串接,但要注意先後順序,會影響事件觸發的條件。

原文如下:

Order matters when using modifiers because the relevant code is generated in the same order. Therefore using @click.prevent.self will prevent click's default action on the element itself and its children, while @click.self.prevent will only prevent click's default action on the element itself.

裡面舉了個例子:

  • @click.prevent.self:會預防元素和他的後代在 click 事件下的預設行為
  • @click.self.prevent:只會預防元素本身在 click 事件下的預設行為

Okay...
聽起來很有道理,腦補一下是蠻合理的,對吧?
然後下次遇到的時又會想不起來,也就是說,根本不知道為什麼是這樣哈哈哈(自我反省時間XD)

所以,今天的目標是:從 DOM 事件傳遞和原始碼了解 v-on 修飾符串接,進而理解 @click.prevent.self@click.self.prevent 的差異。

Outline

  • 了解取消預設行為 - event.preventDefault()
  • vuejs 處理 v-on 修飾符時的相關原始碼

在理解 @click.prevent.self@click.self.prevent 的差異之前,需要先了解 event.preventDefault()!

請看下面的範例或 Codepen,如果你已經知道,為什麼在這裡點擊 <a> 連結並不會跳轉,就可以略過這段,直接到下一段看 vuejs 處理 v-on 修飾符時的相關原始碼。

<div>
      <a href="https://vuejs.org/">Vue 官網</a>
</div>
const outer = document.querySelector("div");
const inner = document.querySelector("a");

outer.addEventListener("click", function (event) {
  console.log("執行 outer handler")
  event.preventDefault();
});

inner.addEventListener("click", function (event) {
  console.log("執行 inner handler")
});

來點原生吧!event.preventDefault()

使用者在瀏覽器觸發事件,會開始一連串的事件傳遞,事件傳遞過程,會經過各個 DOM 元素上的 handler,瀏覽器會:

  1. 先執行 handler
  2. 再響應預設行為(default action)
  • 什麼是預設行為?

使用者操作網頁時會觸發各種事件,有些事件有預設的動作,舉例來說:

  1. 點擊 <a> 連結會導向他帶有的 URL
  2. 點擊 <form> 表單的 submit 按鈕,會向伺服器提交表單
  3. 點擊滑鼠按鈕、移動可以選取網頁文字內容

(最常見的使用情境就是 1 跟 2!)

  • 你可能不了解的 event.preventDefault()

相信大部分人都很熟悉,在綁定事件監聽器時,綁定的 listener / handler 函式可以拿到一個 event 物件,讓我們去取用相關屬性或方法,包含:呼叫 event.preventDefault() 來取消預設行為。

每次觸發事件的時候,event 物件就會經歷 DOM 事件傳遞的機制 ---- 捕獲與冒泡,這個 event 物件會被傳進一路上遇到的 handler 中。

比較容易忽略的地方是,這個 event 物件是同一個,而且在過程中,一旦有 handler 呼叫 event.preventDefault()event 物件下的 defaultPrevented 屬性就會被標記為 true,告訴瀏覽器,要取消這次事件的預設行為。

Calling preventDefault() stops all related default actions of an event object. --W3C

這也是為什麼在剛剛的範例中,雖然 <a> 元素的 handler 上並沒有呼叫 event.preventDefault(),最後卻不會跳轉的原因。

小結

  • 觸發事件後的,事件傳遞過程中,傳進 handler 中的 evnet 物件都是同一個
  • handler 內呼叫 event.preventDefault() 後,event.defaultPrevented 會被標記為 true
  • 所以呼叫 event.preventDefault() 可能會在非(開發者)本意下,不小心影響其他 DOM 元素響應預設行為

v-on 修飾符相關原始碼

part1 - modifierGuards

原始碼連結

modifierGuards 為物件,key 為修飾符,value 為函式,用來回傳判別或呼叫對應方法,後續文章中,我會稱他們為 guard function,可以利用修飾符名稱作為 key ,從 modifierGuards 取得對照的 guard function。

const systemModifiers = ['ctrl', 'shift', 'alt', 'meta']

type KeyedEvent = KeyboardEvent | MouseEvent | TouchEvent

const modifierGuards: Record<
  string,
  (e: Event, modifiers: string[]) => void | boolean
> = {
  stop: e => e.stopPropagation(), //呼叫 event 停止冒泡的 method
  prevent: e => e.preventDefault(), //呼叫 event 取消預設行為的 method
  self: e => e.target !== e.currentTarget,
  ctrl: e => !(e as KeyedEvent).ctrlKey,
  shift: e => !(e as KeyedEvent).shiftKey,
  alt: e => !(e as KeyedEvent).altKey,
  meta: e => !(e as KeyedEvent).metaKey,
  left: e => 'button' in e && (e as MouseEvent).button !== 0,
  middle: e => 'button' in e && (e as MouseEvent).button !== 1,
  right: e => 'button' in e && (e as MouseEvent).button !== 2,
  exact: (e, modifiers) =>
    systemModifiers.some(m => (e as any)[`${m}Key`] && !modifiers.includes(m))
}
  1. .stop v.s .prevent
    這兩個修飾符比較特別,會呼叫對應的 method,回傳 undefined

    • stop: e => e.stopPropagation(), 呼叫 event 停止冒泡的 method
    • prevent: e => e.preventDefault(), 呼叫 event 取消預設行為的 method,將 event.defaultPrevented 標示為 true,表示取消這次事件的預設行為。
  2. .selfself: e => e.target !== e.currentTarget,

    • 判斷並回傳事件的 target 是否為 currentTarget
    • 也就是,事件觸發者(target) 是否為事件監聽者(currentTarget)
    • 兩者相同則回傳 false,兩者不同則回傳 true

註:這邊會覺得像反反邏輯,和 Vue 後面的設計有關。
可以想成 guard function 是要用來擋住「不要的情況」,所以設定上,會在狀況和修飾符不符合的時候,回傳 true,表示 guard 出動、阻擋繼續執行 handler。

  1. 系統按鍵
    判斷並回傳系統鍵是否處於「非按下」的狀態;舉例如:監聽綁定 .ctrl 修飾符,觸發事件時,如果 ctrl 在按下狀態,會回傳 false
ctrl: e => !(e as KeyedEvent).ctrlKey,
shift: e => !(e as KeyedEvent).shiftKey,
alt: e => !(e as KeyedEvent).altKey,
meta: e => !(e as KeyedEvent).metaKey,

註:瀏覽器的 KeyboardEventMouseEventTouchEvent 物件下,有 ctrlKeyshiftKeyaltKeymetaKey 這 4 個屬性,事件觸發當下,系統鍵處於按下狀態則為 true,非按下狀態則為 false,即使監聽的是 keyup 事件,也是一樣的。

  1. 滑鼠按鈕
    判斷並回傳:事件底下是否有 button 屬性,並且數字是否「沒有對應」到修飾符。
left: e => 'button' in e && (e as MouseEvent).button !== 0,
middle: e => 'button' in e && (e as MouseEvent).button !== 1,
right: e => 'button' in e && (e as MouseEvent).button !== 2,
  1. .exact
const systemModifiers = ['ctrl', 'shift', 'alt', 'meta']

//中略
  exact: (e, modifiers) =>
    systemModifiers.some(m => (e as any)[`${m}Key`] && !modifiers.includes(m))
}

systemModifiers 中,是有否修飾符同時具備以下兩個條件:

  1. 在事件觸發時,對應的系統鍵被按住
  2. 不在這次 v-on 的修飾符中

有這種情況會回傳 true,表示不得觸發這次的 handler。

part2 - withModifiers

在 v-on 使用修飾符時,Vue 會呼叫 withModifier 來處理。

//傳入的 fn 為 ($event) => customHandler(param1, param2,...)
//傳入的 modifiers 為陣列,如["prevent", "self"]
export const withModifiers = (fn: Function, modifiers: string[]) => {
  return (event: Event, ...args: unknown[]) => {
    for (let i = 0; i < modifiers.length; i++) {
      const guard = modifierGuards[modifiers[i]]
      if (guard && guard(event, modifiers)) return 
        //這裡的 return 會結束函式的執行,符合條件代表不會繼續往下觸發 customHandler
    }
    return fn(event, ...args)
    //回傳並執行 fn,即 customHandler
  }
}

這裡先說結論,withModifier 會按照修飾符串接的順序,依序判別並呼叫 guard function,修飾符判別都通過後,再執行 handler 函式內容。

對一般判斷性質的修飾符來說,順序其實不太重要,但是對 .prevent修飾符來說,順序大有影響,因為他並不是單純判斷事件觸發的環境,而是直接呼叫方法,去改動這次事件物件下的屬性

如果修飾符中用到 .prevent,並且放在最前面,執行過程就一定會先呼叫 event.preventDefault()無論後面判別是否通過event 物件的 defaultPrevented 屬性已經被標示為 false就算沒有觸發 handler瀏覽器也已經判斷要取消這次的預設行為了

如果對程式碼部份有疑惑的人,可以繼續往下看。


搭配範例看 withModifier

直接從範例來看:

<div @click.prevent.self="logMsg('最外層', $event)">
  <a href="https://vuejs.org/">Vue</a>
<div>

編譯後會將 handler function(logMsg) 和裝有修飾符的字串陣列傳進 withModifiers,像這樣:

withModifiers(($event) => logMsg("\u6700\u5916\u5C64"), ["prevent", "self"])

之後會根據修飾符陣列的長度跑 for 迴圈,遍歷修飾符陣列,利用修飾符名稱作為 key ,從 modifierGuards 去取得對照的 guard function。

    for (let i = 0; i < modifiers.length; i++) {
      const guard = modifierGuards[modifiers[i]]
      if (guard && guard(event, modifiers)) return
    }

以剛剛範例來說明,收到的 modifiers["prevent", "self"]

第一圈會處理 prevent

  1. modifierGuards 內有 "prevent" 對應的 guard function (guardtrue)
  2. 呼叫對應的 guard function,裡面會執行event.preventDefault(),將 event.defaultPrevented 標示為 true,表示取消事件預設行為
    prevent: e => e.preventDefault(),
    
  3. prevent 的 guard function 單純呼叫方法,回傳 undifined,(guard(event, modifiers)false)
  4. 所以繼續跑下一次迴圈,繼續處理 self

第二圈會處理 self

  1. modifierGuards 內有 "self" 對應的 guard function (guardtrue)
  2. 呼叫對應的 guard function
    self: e => e.target !== e.currentTarget,
    
    裡面會判斷並回傳監聽元素與觸發元素是否「不相等」
  3. 分成兩個情況 (guard(event, modifiers))
    • 監聽元素與觸發元素「不同」:
      回傳 truewithModifiers 整個函式直接被 return
    • 監聽元素與觸發元素「相同」
      回傳 false,沒有下一個迴圈要執行,所以結束迴圈,繼續往下執行 return fn(event, ...args),表示直接執行 handler ($event) => logMsg("\u6700\u5916\u5C64")

一定要釐清,這裡的判別結果只影響「會不會執行這個 handler」
不管判別結果是什麼,在上一個迴圈,已經將 event.defaultPrevented 標示為 true,所以,如果這次的事件是從後代 <a> 元素冒泡上來的,最後不會進行頁面跳轉。


剛剛上面的範例是 @click.prevent.self
至於 @click.self.prevent,白話來說,會先判別 self 的條件(監聽元素是否為觸發元素),條件成立的話,才會在下一個迴圈呼叫 event.preventDefault 來取消預設行為,在監聽器註冊在冒泡階段的情況下,也就不會影響到後代元素觸發事件時的預設行為。

簡單總結
@click.prevent.self先取消預設行為,再判斷觸發元素是不是自己,是的話才執行 handle function,但無論執行與否,取消預設行為已經完成
@click.self.prevent只有在確定觸發元素是自己之後,才取消預設行為,接著執行 handle function。

可以到 Vue v-on 範例 去玩玩看兩種順序的差異~

參考資料


上一篇
Day 15: 在 Vue 專案使用 Sass/SCSS +共用變數 (feat. Vite)
下一篇
Day 17: 元件溝通的原則 feat. props & emit
系列文
真的好想離開 Vue 3 新手村 feat. CompositionAPI31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言